Análise Longitudinal do Corpus de Cartas Brasileiras (PHPB-Ba)
Valter Moreno
valter.moreno@eng.uerj.br
**Fundação Getúlio Vargas (FGV)**
**Escola de Matemática Aplicada (EMAp)**
**Mestrado em Matemática Aplicada**
**Sistemas de Recuperação de Informação - 2019**
**Prof. Flavio Codeço**
Índice
%%javascript
$.getScript('https://kmahelona.github.io/ipython_notebook_goodies/ipython_notebook_toc.js')
Neste projeto, analisamos os manuscritos modernizados do Corpus Eletrônico de Documentos Históricos do Sertão (CE-DOHS) com os seguintes objetivos:
Além de obtermos resultados globais para todo o corpus, dividimos os documentos de acordo com as décadas de sua produção, de forma a avaliar mudanças em assuntos e no significado de palavras ao longo do tempo.
O Corpus Compartilhado Diacrônico – Cartas Brasileiras (PHPB-Ba) é disponibilizado no website do projeto Plataforma Corpus Eletrônico de Documentos Históricos do Sertão CE-DOHS. O projeto é coordenado por Zenaide de Oliveira Novais Carneiro (UEFS/Fapesb/CNPq) e Mariana Fagundes de Oliveira Lacerda (UEFS/Fapesb).
O corpus analisado neste projeto consiste de 1.185 cartas com datas no período de 1823 a 2000. As cartas provêm de 14 corporas distintos, listados no website do CE-DOHS.
import numpy as np
import os
import glob
import re
import pandas as pd
pd.set_option('precision', 5)
pd.set_option('display.max_colwidth', -1) # Customização do display de dataframes para que
# todo o conteúdo das colunas seja mostrado
import matplotlib.pyplot as plt
%config InteractiveShellApp.pylab_import_all = False
%pylab inline
import seaborn as sns
sns.set(palette='dark', color_codes=True)
from plotly.offline import init_notebook_mode, iplot, plot
import plotly.graph_objs as go
from sklearn.decomposition import IncrementalPCA
from sklearn.manifold import TSNE
from collections import defaultdict, Counter
from datetime import datetime
from pprint import pprint
from wordcloud import WordCloud
from string import punctuation
from random import randint, sample
# Gensim
import gensim
from gensim import corpora, models
from gensim.utils import simple_preprocess
from gensim.models import TfidfModel, LsiModel, Word2Vec, word2vec
from gensim.corpora import MmCorpus
from gensim.test.utils import datapath, get_tmpfile
# spaCy
import spacy
nlp = spacy.load("pt_core_news_sm")
# NLTK
import nltk
from nltk.corpus import stopwords
nltk.download('stopwords')
# Avisos:
import warnings
warnings.filterwarnings("ignore",category=DeprecationWarning)
# Função para a impressão de conteúdo Markdown
from IPython.display import Markdown, display
def printmd(string):
display(Markdown(string))
O corpus de 1.185 cartas está dividido em arquivos de texto com o conteúdo original e modernizado. Em nossa análise, utilizamos apenas as cartas com o português modernizado. Um exemplo de manuscrito modernizado é mostrado a seguir.
caminho = "CE-DOHS/*.txt"
arquivos = glob.glob(caminho)
printmd('**Há um total de <font color="gray">' + str(len(arquivos)) + '</font> arquivos no diretório do corpus.**\n')
printmd('**Nomes dos primeiros arquivos:**')
for i in range(0,8):
print(arquivos[i])
arquivos = [id for id in arquivos if id.endswith('-mod.txt')]
printmd('**<br/>Conteúdo do primeiro arquivo com português modernizado:**')
with open(arquivos[0]) as f:
texto = [linha.strip(' \n') for linha in f.readlines() if len(linha.strip(' \n')) > 0]
pd.DataFrame(texto, columns=['']).style.set_properties(**{'text-align': 'left'})
Os arquivos do CE-DOHS incluem metadados em seu cabeçalho identificados pela sequência de caracteres |:|. Os seguintes campos são fornecidos:
Destes, optamos por manter a identificação da carta, o rementente e o destinatário, e a data. Calculamos também o número de palavras em cada documento. Esses metadados foram armazenados num dataframe, confome mostrado posteriormente.
printmd('**Metadados do primeiro arquivo:**')
pprint([meta for meta in texto if meta.startswith('|:|')])
Para otimizar o uso da memória do computador, criamos uma classe que, quando instanciada, processa os documentos do corpus iterativamente, extraindo os metadados e gravando o texto processado num novo arquivo. O texto de cada carta foi processado da seguinte forma:
class CartasCorpus:
def __init__(self, arquivos, pasta, ndocs=100, inicio=10):
self.arquivos = arquivos
self.pasta = pasta
self.ndocs = min(len(arquivos),ndocs)
self.inicio = inicio
'''
Para cada os ndocs primeiros arquivos do corpus, os metadados são extraídos e adicionados
ao dataframe passado na criação do objeto. Além disso, o conteúdo do arquivo é processado
de acordo com o tipo de processamento especificado, e gravado num arquivo com o mesmo ID do
arquivo original e o sufixo "-proc.txt".
'''
def __iter__(self):
for fileid in self.arquivos[:self.ndocs]:
meta_dic = dict()
''' Lê o conteúdo do arquivo numa lista de linhas:
'''
with open(fileid, mode="r", encoding="utf-8") as doc:
texto = doc.read()
linhas = [linha for linha in texto.split('\n') if len(linha) > 0]
''' Cria um dicionário a partir das linhas de metadados:
'''
meta_dic['ID'] = fileid.replace('CE-DOHS/','').replace('-mod.txt','')
meta_dic['Palavras'] = len(texto.split())
rem = [autor.replace(u'|:| Autor:', '').strip(' .\n') \
for autor in linhas \
if u'|:| Autor:' in autor][0]
meta_dic['Remetente'] = rem if len(rem) > 0 else np.NaN
dest = [dest.replace(u'|:| Destinatário:', '').strip(' .\n') \
for dest in linhas \
if u'|:| Destinatário:' in dest][0]
meta_dic['Destinatário'] = dest if len(dest) > 0 else np.NaN
''' Tenta obter a data da carta:
'''
data = [data.replace(u'|:| Data:', '').strip(' .\n').lower() \
for data in linhas \
if u'|:| Data:' in data][0]
meses = ['janeiro','fevereiro','mar','abril','maio','junho',
'julho','agosto','setembro','outubro','novembro','dezembro']
mes = [mes for mes in meses if mes in data]
if mes:
data = data.replace(mes[0], '/' + str(meses.index(mes[0]) + 1) + '/')
data_num = re.compile('[0-9\/]')
data = data_num.findall(data)
data = ''.join(data)
try:
data_dt = pd.to_datetime(data, infer_datetime_format=True)
if pd.isna(data_dt) or data_dt.year > 2000 or data_dt.year < 1823:
data = self.data_id(fileid)
else:
data = data_dt.strftime("%d/%m/%Y")
except:
data = self.data_id(fileid)
meta_dic['Data'] = data
''' Processa as linhas e retorna uma nova lista com elementos do
tipo especificado:
'''
texto = self.proc_texto(linhas, self.inicio)
filtradas = 0
for linha in texto:
filtradas += len(linha.split())
meta_dic['Filtradas'] = filtradas
''' Grava o arquivo com o texto processado no diretório 'CE-DOHS-proc/':
'''
if not os.path.exists(self.pasta):
os.makedirs(self.pasta)
nome = self.pasta + meta_dic['ID'] + '-proc.txt'
with open(nome,'w+') as arquivo:
arquivo.writelines("%s\n" % linha for linha in texto)
yield meta_dic
def data_id(self, fileid):
'''
No corpus Corpus Eletrônico de Documentos Históricos do Sertão, a maior parte dos
nomes dos arquivos inclui a data da carta. Esta função tenta recuperar essa data
a partir do nome do arquivo. O resultado gerado é um objeto to tipo datetime ou NaN.
'''
data_num = re.compile('[0-9]{2}-[0-9]{2}-[0-9]{4}')
data = data_num.findall(fileid)
if data:
try:
data_dt = pd.to_datetime(data[0], format='%d-%m-%Y')
if pd.isna(data_dt):
return np.NaN
else:
return data_dt.strftime("%d/%m/%Y")
except:
return np.NaN
else:
return np.NaN
def proc_texto(self, linhas, inicio):
'''
Processa cada linha do conteúdo do documento, a partir da linha de inicial passada (inicio),
de acordo com o tipo de processamento especificado. Retorna uma lista com os resultados.
'''
''' Converte as linhas do texto num único string:
'''
texto = linhas[inicio:len(linhas)]
texto = '\n'.join(texto)
''' Elimina caracteres e comentários indesejáveis:
'''
texto = texto.replace('[pag]', '')
texto = re.sub('\[footer:([\\n a-zA-Z0-9áàâãéèêíïóôõöúçñÁÀÂÃÉÈÍÏÓÔÕÖÚÇÑ.,!?;:<>()-_#\'\"‘’“”]*)\]',
'', texto)
texto = re.sub('\[header:[ ]*\]', '\n', texto)
texto = re.sub('[|]*[ ]*[Ff]l. [0-9] [rv]', ' ', texto)
texto = re.sub('[0-9][ ]*Rasura[do]*[ .]*', ' ', texto)
texto = re.sub('[0-9][ ]*Borrado[ .]*', ' ', texto)
texto = re.sub('\[[ ]*[Rr]ubrica[ ]*\]', ' ', texto)
texto = re.sub('\[[ ]*[Rr]asura[ ]*\]', ' ', texto)
texto = re.sub('\[[ ]*[Ii]nint[ ]*.[ ]*\]', ' ', texto)
texto = re.sub('Sr. ', 'Sr ', texto)
texto = re.sub('Sra. ', 'Sra ', texto)
texto = re.sub('Dr. ', 'Dr ', texto)
texto = re.sub('Dra. ', 'Dra ', texto)
texto = re.sub('V. ', 'V ', texto)
texto = re.sub('Exa. ', 'Exa ', texto)
texto = re.sub(' = ', '', texto)
texto = re.sub(u'[↑¹²³⁴⁵⁶⁷⁸⁹|<>\(\)\[\]‘’“”\'\"]+', '', texto)
texto = re.sub(r' +', ' ', texto)
texto = texto.strip()
linhas = [linha for linha in texto.split('\n') if len(linha) > 0]
linhas = self.quebra_linhas(linhas, ['?', '!', '.', ';', '–'])
return linhas
def quebra_linhas(self, linhas, pontos):
for ponto in pontos:
novas = []
for linha in linhas:
if ponto in linha:
linha=linha.replace(ponto, ' ' + ponto + '\n')
novas.extend([l.strip()
for l in linha.split('\n')
if len(l.strip(punctuation).strip()) > 0])
else:
if len(linha.strip(punctuation).strip()) > 0:
novas.extend([linha.strip()])
linhas = [linha for linha in novas]
return novas
Instanciamos a classe criando um objeto que se comporta como um iterador. O processamento dos textos do corpus só é efetivamente realizado ao longo das iterações do loop for doc in dic: no trecho de código a seguir.
metadados = pd.DataFrame(columns=['ID', 'Palavras', 'Filtradas', 'Remetente', 'Destinatário', 'Data'])
dic = CartasCorpus(arquivos, 'CE-DOHS-proc/', ndocs=10000)
for doc in dic:
metadados = metadados.append(doc, ignore_index=True)
Abaixo, motramos o dataframe com os resultados da extração dos metadados dos textos do corpus.
metadados
O conteúdo processado de um arquivo escolhido aleatoriamente é exibido a seguir.
id = metadados.ID[randint(0, 1184)]
fileid = 'CE-DOHS-proc/' + id + '-proc.txt'
with open(fileid, mode="r", encoding="utf-8") as doc:
linhas = [linha.strip('\n') for linha in doc.readlines()]
printmd('**Carta ' + id + ':**')
for linha in linhas:
pprint(linha)
Os documentos do corpus são cartas, geralmente de tamanho reduzido, como mostram o histograma e as estatísticas descrivas abaixo. Setenta e cinco por cento das cartas têm até 455 palavras, incluindo o cabeçalho com os metadados (nome do corpus, pessoa responsável, identificação da carta, autor, destinatário, data, versão, e codificação).
ax = sns.distplot(metadados.Palavras)
ax.set_title('Número de Palavras nos Documentos do Corpus')
plt.show()
printmd('**Estatísticas descritivas do número de palavras:**')
print(round(pd.to_numeric(metadados.Palavras).describe(),1))
Uma vez que o pré-processamento dos textos incluiu a eliminação de metadados e comentários incluídos nos documentos originais, o número de palavras nos documentos processados é menor do que nos originais.
ax = sns.distplot(metadados.Filtradas)
ax.set_title('Número de Tokens nos Documentos Processados')
plt.show()
printmd('**Estatísticas descritivas para os documentos processados:**')
print(round(pd.to_numeric(metadados.Filtradas).describe(),1))
Uma vez que desejávamos avaliar as diferenças entre tópicos e significados de palavras ao longo tempo, era importante avaliar como os textos analisados estavam distribuídos ao longo do período compreendido no corpus (1823-2000). O gráfico a seguir mostra a evolução do número de cartas disponíveis em cada ano desse período.
anos = [data[-4:] if not pd.isnull(data) else np.nan for data in metadados.Data.tolist()]
anos = [int(ano) if not pd.isnull(ano) else np.nan for ano in anos]
freq = pd.DataFrame(Counter(anos).items(), columns=['Ano','Frequência']).sort_values(by='Ano')
ax = sns.lineplot(x='Ano', y='Frequência', data=freq)
ax.set_title('Número de Cartas no Corpus por Ano')
plt.show()
O gráfico acima mostra diferenças substanciais no número de documentos por ano. Além disso, na maior parte dos anos, a frequência é inferior a 30 documentos. Antevendo a necessidade de treinamento dos modelos usados nas análises posteriores, decidimos avaliar possibilidades de agregação por intervalo de tempo (ex., 10 em 10 anos, 20 em 20, etc.). Os resultados para algumas alternativas são apresentados nos próximos gráficos.
def bar_periodo(dados, inicio=1823, periodo=10, fim=2000):
freq_dic = dict()
freq_dic[inicio] = freq.loc[[ano <= inicio \
if not pd.isnull(ano) else False \
for ano in freq.Ano]]['Frequência'].sum()
for decada in range(inicio, fim, periodo):
limite = decada + periodo if decada + periodo < fim else 2000
freq_dic[limite] = freq.loc[[(ano > decada and ano <= limite) \
if not pd.isnull(ano) else False \
for ano in freq.Ano]]['Frequência'].sum()
freq_periodo = pd.DataFrame(columns=['Ano', 'Frequência'])
freq_periodo.Ano = freq_dic.keys()
freq_periodo.Frequência = freq_dic.values()
ax = sns.barplot(x='Ano', y='Frequência', data=freq_periodo)
ax.set_xticklabels(ax.get_xticklabels(), rotation=45)
ax.set_title('Número de Cartas por Período')
plt.show()
bar_periodo(freq, 1820, 10, 2000)
bar_periodo(freq, 1820, 20, 2000)
bar_periodo(freq, 1880, 20, 1980)
bar_periodo(freq, 1890, 30, 1980)
A opção de agrupamento acima é bem equilibrada em termos de número de documentos por período (entre 250 e 300 cartas). Além disso, inclui um número de faixas suficiente para representar períodos importantes da história brasileira:
Esses períodos foram adotados na análise longitudinal de tópicos e significados, descrita posteriormente.
Os metadados coletados incluem informações do remetente e do destinatário de uma carta. Verificamos que o número de remetentes únicos é bastante superior ao de destinatários. Além disso, ambos representam percentuais relativamente baixos do total de cartas do corpus. Logo, há mais de uma carta de um mesmo remetente, e várias cartas para um mesmo destinatário.
remetentes = len(metadados.Remetente.unique())
destinatarios = len(metadados.Destinatário.unique())
n_cartas = metadados.shape[0]
printmd('**Número de remetentes únicos no corpus: <font color="gray">' +
str(remetentes) + ' (' + str(round(100*remetentes/n_cartas, 2)) + '%)</font>**')
printmd('**Número de destinatários únicos no corpus: <font color="gray">' +
str(destinatarios) + ' (' +
str(round(100*destinatarios/n_cartas, 2)) + '%)</font>**')
O histograma do número de cartas de cada remetente indica que a vasta maioria dos remetentes escreveu menos de 10 cartas. Em torno de 3.2% dos remententes no corpus foram autores de mais de 10 documentos.
freq_rem = pd.DataFrame(Counter(metadados.Remetente).items(),
columns=['Remetente', 'Frequência'])\
.sort_values(by=['Frequência'], ascending=False)
ax = sns.distplot(freq_rem.Frequência, kde=False, rug=True)
ax.set_title('Histograma do Número de Cartas por Remetente')
plt.show()
n_rem = len(freq_rem.loc[freq_rem.Frequência > 10])
printmd('**Número de remetentes com mais de 10 cartas no corpus: <font color="gray">' +
str(n_rem) + ' (' + str(round(100*n_rem/remetentes, 2)) + '%)</font>**')
printmd('<br/>**Remetentes de mais de 10 cartas:**')
freq_rem.loc[freq_rem.Frequência > 10]
Da mesma forma, vemos que poucas pessoas foram destinatárias de mais de 10 cartas.
freq_dest = pd.DataFrame(Counter(metadados.Remetente).items(),
columns=['Destinatário', 'Frequência'])\
.sort_values(by=['Frequência'], ascending=False)
ax = sns.distplot(freq_dest.Frequência, kde=False, rug=True)
ax.set_title('Histograma do Número de Cartas por Destinatário')
plt.show()
n_dest = len(freq_dest.loc[freq_dest.Frequência > 10])
printmd('**Número de destinatários com mais de 10 cartas no corpus: <font color="gray">' +
str(n_dest) + ' (' +
str(round(100*n_dest/destinatarios, 2)) + '%)</font>**')
printmd('<br/>**Destinatários de mais de 10 cartas:**')
freq_dest.loc[freq_dest.Frequência > 10]
Para a modelagem de tópicos do corpus, utilizamos o algoritmo de Indexação Semântica Latente (Latent Semantic Indexing - LSI) da biblioteca gensim.
Inicialmente, mantivemos os textos das cartas originais sem alterações. Após os tokenizarmos, retendo a capitalização original e os stop words, criamos um modelo LSI com os 100 maiores autovalores na transformação SVD (num_topics) implementada no algoritmo. De acordo com Bradford (2008), para um corpus com milhões de documentos, valores entre 300 e 500 tendem a gerar resultados satisfatórios, enquanto valores entre 70 e 100 podem ser mais adequados para corpora menores (Dumais, 1991.
Em seguida, repetimos a análise usando textos tokenizados, sem stop words, sem nomes próprios, em minúsculas, e lematizados. Para isso, usamos os seguintes procedimentos:
Como feito antes, para otimizar o uso da memória, criamos uma classe que implementa um iterador. Quando objeto da classe é iterado, o arquivo referente a uma carta do corpus é lido, e seu conteúdo é tokenizado e alimentado ao corpus e ao dicionário do gensim. O corpus utiliza o modelo bag of words.
A implementação da classe permite que, na criação do iterador, se defina se os stop words e nomes próprios serão removidos, se haverá lematização, e se os tokens serão passados para minúsculas. Tokens com apenas um caracter são removidos.
Criação do conjunto de stop words:
spacy_stopwords = spacy.lang.pt.stop_words.STOP_WORDS
printmd('**Número de *stop words* em português no spaCy: <font color="gray">' + str(len(spacy_stopwords)) +
'</font>**')
printmd('**Primeiros *stop words* na lista:**')
print(sorted(list(spacy_stopwords))[:20])
nltk_stopwords = sorted(nltk.corpus.stopwords.words('portuguese'))
printmd('**Número de *stop words* em português no nlkt: <font color="gray">' + str(len(nltk_stopwords)) +
'</font>**')
printmd('**Primeiros *stop words* na lista:**')
print(nltk_stopwords[:20])
adicionais = {'você', 'vosmecê', 'porém', 'todavia', 'contudo', 'entretanto', 'entanto', 'logo', 'pois',
'assim', 'conseguinte', 'nem', 'também', 'obstante', 'talvez', 'logo', 'pois', 'portanto',
'isso', 'aquilo', 'aquiloutro', 'porquanto', 'senão', 'pelo', 'pelos', 'pela', 'pelas', 'dr', 'dra', 'sr', 'sra', 'srta',
'doutor', 'doutora', 'senhor', 'senhora', 'senhorita', 'v', 'sa', 'vossa', 'senhoria',
'exa', 'excelência', 'ema', 'eminência', 's', 'santidade', 'rev', 'reverendíssima',
'alteza', 'm', 'majestade', 'magnificência', 'excelentíssimo', 'excelentíssima',
'excelentíssimos', 'excelentíssimas', 'ilustríssimo', 'ilustríssimos', 'ilustríssima',
'ilustríssimas', 'compadre', 'comadre', 'prezado', 'prezada', 'compadres',
'comadres', 'prezados', 'prezadas', 'mim', 'comigo', 'ti', 'contigo', 'ele', 'ela', 'si',
'consigo', 'nós', 'conosco', 'vós', 'convosco', 'eles', 'elas', 'si', 'consigo',
'me', 'te', 'o', 'a', 'se', 'lhe', 'lha', 'lho', 'nos', 'vos', 'os', 'as', 'se', 'lhes',
'lhos', 'lhas', 'lo', 'los', 'la', 'los', 'no', 'nos', 'na' , 'nas', 'meu', 'minha',
'meus', 'minhas', 'teu', 'tua', 'teus', 'tuas', 'seu', 'sua', 'seus', 'suas',
'nosso', 'nossa', 'nossos', 'nossos', 'vosso', 'vossa', 'vossos', 'vossas', 'um', 'uns', 'uma',
'umas', 'muito', 'muita', 'muitos', 'muitas', 'pouco', 'poucos', 'pouca', 'poucas', 'mesmo',
'mesmos', 'mesmo', 'mesmas'}
adicionais = adicionais.union([x.capitalize() for x in list(adicionais)])
stopwords = set(spacy_stopwords).union(nltk_stopwords)
stopwords = stopwords\
.union([word.capitalize() for word in list(stopwords)])\
.union(adicionais)
stopwords = list(stopwords)
printmd('**Número de *stop words* nas duas bibliotecas: <font color="gray">' + str(len(stopwords)) +
'</font>**')
printmd('**Primeiros *stop words* na lista:**')
print(sorted(stopwords)[:50])
with open('stopwords.txt', 'w+') as file:
file.writelines("%s\n" % word for word in sorted(stopwords))
Definição da classe para a criação de corpus no gemsin:
class GensimCorpus:
def __init__(self, pasta, IDs, sufixo, dicionario, stop_words,
lower=True, lema=False, nomes=False, num=False):
self.pasta = pasta
self.IDs = IDs
self.sufixo = sufixo
self.dicionario = dicionario
self.stop = stop_words
self.lower = lower
self.lema = lema
self.nomes = nomes
self.num = num
def __iter__(self):
for id in self.IDs:
fileid = self.pasta + id + self.sufixo
with open(fileid, mode="r", encoding="utf-8") as doc:
texto = doc.read()
texto = nlp(texto)
if self.nomes and not self.lema:
tokens = [token.orth_ for token in texto if token.pos_ != 'PROPN']
elif self.lema and not self.nomes:
tokens = [token.lemma_ for token in texto]
elif self.nomes and self.lema:
tokens = [token.lemma_ for token in texto if token.pos_ != 'PROPN']
else:
tokens = [token.orth_ for token in texto]
if self.lower:
tokens = [token.lower() for token in tokens]
tokens = [token for token in tokens
if token not in self.stop and len(token) > 1]
if self.num:
tokens = [re.sub('[$:0-9]*', '', token)
for token in tokens]
tokens = [token for token in tokens if len(token) > 1]
self.dicionario.add_documents([tokens])
yield self.dicionario.doc2bow(tokens)
if not os.path.exists('Modelos/'): # se necessário, cria pasta para corpora dos modelos
os.makedirs('Modelos/')
Na primeira análise, criamos o corpus do gensim com os textos tokenizados, sem a exclusão de stop words e nomes próprios, lematização, ou passagem dos tokens para minúsculas.
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', metadados.ID , '-proc.txt', dic_gensim, [],
lower=False, lema=False, nomes=False, num=False)
Como sugerido no tutorial do gensim, a transformação LSI recebeu como entrada o resultado da transformação do corpus para o espaço tf-idf.
if os.path.exists('Modelos/modelo_tfidf_min.md') and \
os.path.exists('Modelos/corpus_tfidf_min.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_min.md') # carrega o modelo tf-idf
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_min.cp') # carrega o corpus tf-idf
else:
modelo_tfidf = TfidfModel(corpus_gensim, normalize=True) # inicialização da trasnformação tf-idf
corpus_tfidf = modelo_tfidf[corpus_gensim] # transformação do corpus
modelo_tfidf.save('Modelos/modelo_tfidf_min.md') # salva o modelo tf-idf
MmCorpus.serialize('Modelos/corpus_tfidf_min.cp', corpus_tfidf) # salva o corpus tf-idf
O modelo LSI foi criado com o corpus transformado para o espaço tf-idf.
if os.path.exists('Modelos/modelo_lsi_min_100.md') and \
os.path.exists('Modelos/corpus_lsi_min_100.cp'):
modelo_lsi = LsiModel.load('Modelos/modelo_lsi_min_100.md')
corpus_lsi = MmCorpus('Modelos/corpus_lsi_min_100.cp')
else:
modelo_lsi = LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100)
corpus_lsi = modelo_lsi[corpus_tfidf]
modelo_lsi.save('Modelos/modelo_lsi_min_100.md')
MmCorpus.serialize('Modelos/corpus_lsi_min_100.cp', corpus_lsi)
O primeiro tópico, com os pesos dos primeiros 100 tokens, é mostrado abaixo.
printmd('**100 primeiras palavras do primeiro tópico identificado:**')
pprint(modelo_lsi.print_topics(num_topics=1, num_words=100)[0][1])
Para facilitar a visualização e análise dos resultados, geramos os primeiros 50 tokens dos cinco tópicos iniciais, sem os seus respectivos pesos.
palavras = []
for i in range(50):
palavras.append([word for (word, load) in modelo_lsi.show_topic(i, 100) if len(word) > 1])
if i < 5:
printmd('**Tópico ' + str(i+1) + ':**')
print(palavras[i][:50])
n_topicos = set(range(5))
for i in list(n_topicos):
outros = n_topicos - set([i])
set_outros = set()
for j in outros:
set_outros = set_outros.union(palavras[j][:50])
printmd('**Tokens apenas no tópico ' + str(i+1) + ':**')
print(set(palavras[i][:50]) - set_outros)
O conjunto das 100 palavras mais importantes de cada um dos 50 primeiros tópicos foi usado para criar a nuvem de palavras exibida abaixo.
todas = palavras[0]
for i in range(1,50):
todas.extend(palavras[i])
nuvem = ' '.join(todas)
# Gera a nuvem de palavras
wordcloud = WordCloud().generate(nuvem)
# take relative word frequencies into account, lower max_font_size
wordcloud = WordCloud(background_color="white",
max_words=len(todas),
max_font_size=40,
relative_scaling=.6) \
.generate(nuvem)
plt.figure(figsize = (10, 10), facecolor = None)
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()
De forma geral, os tópicos obtidos são pouco informativos. Em todos, prevalecem nomes próprios, números e pronomes, além de haver verbos flexionados e palavras que aparecem capitalizadas e em minúsculas, dificultando a interpretação dos resultados.
Os passos anteriores foram repetidos, adicionando-se, na criação do corpus do gensim, as opções de remoção de stop words, nomes próprios e números, e passagem dos tokens para minúsculas.
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', metadados.ID, '-proc.txt', dic_gensim, stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_tfidf_rm.md') and \
os.path.exists('Modelos/corpus_tfidf_rm.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_rm.md') # carrega o modelo tf-idf
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_rm.cp') # carrega o corpus tf-idf
else:
modelo_tfidf = models.TfidfModel(corpus_gensim, normalize=True) # inicialização da trasnformação tf-idf
corpus_tfidf = modelo_tfidf[corpus_gensim] # transformação do corpus
modelo_tfidf.save('Modelos/modelo_tfidf_rm.md') # salve o modelo tf-idf
MmCorpus.serialize('Modelos/corpus_tfidf_rm.cp', corpus_tfidf) # salva o corpus tf-idf
if os.path.exists('Modelos/modelo_lsi_100_rm.md') and \
os.path.exists('Modelos/corpus_lsi_100_rm.cp'):
modelo_lsi = LsiModel.load('Modelos/modelo_lsi_100_rm.md') # carrega o modelo lsi
corpus_lsi = MmCorpus('Modelos/corpus_lsi_100_rm.cp') # carrega o corpus lsi
else:
modelo_lsi = models.LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100) # inicialização
corpus_lsi = modelo_lsi[corpus_tfidf] # transformação do corpus
modelo_lsi.save('Modelos/modelo_lsi_100_rm.md') # salva o modelo lsi
MmCorpus.serialize('Modelos/corpus_lsi_100_rm.cp', corpus_lsi) # salva o corpus lsi
printmd('**100 primeiras palavras do primeiro tópico identificado:**')
pprint(modelo_lsi.print_topics(num_topics=1, num_words=100)[0][1])
Para facilitar a visualização dos resultados, mostramos abaixo os primeiros 50 tokens dos cinco tópicos iniciais, sem os seus respectivos pesos.
palavras = []
for i in range(50):
palavras.append([word for (word, load) in modelo_lsi.show_topic(i, 100) if len(word) > 1])
if i < 5:
printmd('**Tópico ' + str(i+1) + ':**')
print(palavras[i][:50])
n_topicos = set(range(5))
for i in list(n_topicos):
outros = n_topicos - set([i])
set_outros = set()
for j in outros:
set_outros = set_outros.union(palavras[j][:50])
printmd('**Tokens apenas no tópico ' + str(i+1) + ':**')
print(sorted(set(palavras[i][:50]) - set_outros))
O conjunto das 100 palavras mais importantes de cada um dos 50 primeiros tópicos foi usado para criar a nuvem de palavras exibida abaixo.
todas = palavras[0]
for i in range(1,50):
todas.extend(palavras[i])
nuvem = ' '.join(todas)
# Gera a nuvem de palavras
wordcloud = WordCloud().generate(nuvem)
# take relative word frequencies into account, lower max_font_size
wordcloud = WordCloud(background_color="white",
max_words=len(todas),
max_font_size=40,
relative_scaling=.6) \
.generate(nuvem)
plt.figure(figsize = (10, 10), facecolor = None)
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()
As primeiras 50 palavras dos tópicos gerados incluem diversas palavras em comum, grande parte delas refletindo termos tipicamente encontrados em cartas, como saudações e expressões de cunho afetivo. Embora sua ordenação seja razoavelmente distinta em cada tópico, refletindo diferenças em grau de importância na constituição do tópico, ela não permite diferenciar claramente os significados dos temas.
Apesar disso, pode-se notar uma aparente distinção entre os quatro primeiros tópicos e o quinto:
Nesta etapa, além da remoção de stop words, nomes próprios e números, e da passagem dos tokens para minúsculas, incluímos a lematização na criação do corpus do gensim.
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', metadados.ID, '-proc.txt', dic_gensim, stopwords,
lower=True, lema=True, nomes=True, num=True)
if os.path.exists('Modelos/modelo_tfidf_lem.md') and \
os.path.exists('Modelos/corpus_tfidf_lem.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_lem.md') # carrega o modelo tf-idf
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_lem.cp') # carrega o corpus tf-idf
else:
modelo_tfidf = models.TfidfModel(corpus_gensim, normalize=True) # inicialização da trasnformação tf-idf
corpus_tfidf = modelo_tfidf[corpus_gensim] # transformação do corpus
modelo_tfidf.save('Modelos/modelo_tfidf_lem.md') # salve o modelo tf-idf
MmCorpus.serialize('Modelos/corpus_tfidf_lem.cp', corpus_tfidf) # salva o corpus tf-idf
if os.path.exists('Modelos/modelo_lsi_100_lem.md') and \
os.path.exists('Modelos/corpus_lsi_100_lem.cp'):
modelo_lsi = LsiModel.load('Modelos/modelo_lsi_100_lem.md') # carrega o modelo lsi
corpus_lsi = MmCorpus('Modelos/corpus_lsi_100_lem.cp') # carrega o corpus lsi
else:
modelo_lsi = models.LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100) # inicialização
corpus_lsi = modelo_lsi[corpus_tfidf] # transformação do corpus
modelo_lsi.save('Modelos/modelo_lsi_100_lem.md') # salva o modelo lsi
MmCorpus.serialize('Modelos/corpus_lsi_100_lem.cp', corpus_lsi) # salva o corpus lsi
printmd('**100 primeiras palavras do primeiro tópico identificado:**')
pprint(modelo_lsi.print_topics(num_topics=1, num_words=100)[0][1])
Seguem os primeiros 50 tokens dos dez tópicos iniciais, sem os seus respectivos pesos.
palavras = []
for i in range(50):
palavras.append([word for (word, load) in modelo_lsi.show_topic(i, 100) if len(word) > 1])
if i < 5:
printmd('**Tópico ' + str(i+1) + ':**')
print(palavras[i][:50])
n_topicos = set(range(5))
for i in list(n_topicos):
outros = n_topicos - set([i])
set_outros = set()
for j in outros:
set_outros = set_outros.union(palavras[j][:50])
printmd('**Tokens apenas no tópico ' + str(i+1) + ':**')
print(sorted(set(palavras[i][:50]) - set_outros))
O conjunto das 100 palavras mais importantes de cada um dos 50 primeiros tópicos foi usado para criar a nuvem de palavras exibida abaixo.
todas = palavras[0]
for i in range(1,50):
todas.extend(palavras[i])
nuvem = ' '.join(todas)
# Gera a nuvem de palavras
wordcloud = WordCloud().generate(nuvem)
# take relative word frequencies into account, lower max_font_size
wordcloud = WordCloud(background_color="white",
max_words=len(todas),
max_font_size=40,
relative_scaling=.6) \
.generate(nuvem)
plt.figure(figsize = (10, 10), facecolor = None)
plt.imshow(wordcloud, interpolation='bilinear')
plt.axis("off")
plt.show()
O modelo de lematização em português da biblioteca spaCy claramente apresenta problemas. Diversos substantivos parecem ter sido transformados em verbos incorretamente, como 'amiga' para 'amigar', 'carta' para 'cartar', 'casa' para 'casar', e 'beijo' para 'beijar'. Os resultados reportados em artigos e blogs confirmam essa limitação, que parece não ser não intensa para a língua inglesa (ex. https://lars76.github.io/nlp/lemmatize-portuguese/; https://towardsdatascience.com/state-of-the-art-multilingual-lemmatization-f303e8ff1a8).
Dado que a implementação da lematização gerou tópicos menos claros do que os obtidos anteriormente, o método não será utilizado nas análises seguintes.
Os quatro períodos definidos anteriormente (1823-1890, 1890-1920, 1920-1950 e 1950-2000) foram usados para dividir os textos em quatro corpora. Para cada um deles, repetimos a análise com o modelo LSI e a opção de remoção de stop words e nomes próprios, lematização, e passagem para minúsculas.
Criação dos corpora e dos modelos LSI:
anos = metadados.Data.str.slice(start=6)
IDs_1890 = metadados.ID[anos <= '1890']
IDs_1920 = metadados.ID[anos.between('1891', '1920', inclusive = True)]
IDs_1950 = metadados.ID[anos.between('1921', '1950', inclusive = True)]
IDs_2000 = metadados.ID[anos > '1950']
# 1823-1890:
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', IDs_1890, '-proc.txt', dic_gensim, stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_tfidf_1890.md') and \
os.path.exists('Modelos/corpus_tfidf_1890.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_1890.md')
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_1890.cp')
else:
modelo_tfidf = models.TfidfModel(corpus_gensim, normalize=True)
corpus_tfidf = modelo_tfidf[corpus_gensim]
modelo_tfidf.save('Modelos/modelo_tfidf_1890.md')
MmCorpus.serialize('Modelos/corpus_tfidf_1890.cp', corpus_tfidf)
if os.path.exists('Modelos/modelo_lsi_100_1890.md') and \
os.path.exists('Modelos/corpus_lsi_100_1890.cp'):
modelo_lsi_1890 = LsiModel.load('Modelos/modelo_lsi_100_1890.md')
corpus_lsi_1890 = MmCorpus('Modelos/corpus_lsi_100_1890.cp')
else:
modelo_lsi_1890 = models.LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100)
corpus_lsi_1890 = modelo_lsi[corpus_tfidf]
modelo_lsi_1890.save('Modelos/modelo_lsi_100_1890.md')
MmCorpus.serialize('Modelos/corpus_lsi_100_1890.cp', corpus_lsi)
# 1890-1920:
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', IDs_1920, '-proc.txt', dic_gensim, stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_tfidf_1920.md') and \
os.path.exists('Modelos/corpus_tfidf_1920.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_1920.md')
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_1920.cp')
else:
modelo_tfidf = models.TfidfModel(corpus_gensim, normalize=True)
corpus_tfidf = modelo_tfidf[corpus_gensim]
modelo_tfidf.save('Modelos/modelo_tfidf_1920.md')
MmCorpus.serialize('Modelos/corpus_tfidf_1920.cp', corpus_tfidf)
if os.path.exists('Modelos/modelo_lsi_100_1920.md') and \
os.path.exists('Modelos/corpus_lsi_100_1920.cp'):
modelo_lsi_1920 = LsiModel.load('Modelos/modelo_lsi_100_1920.md')
corpus_lsi_1920 = MmCorpus('Modelos/corpus_lsi_100_1920.cp')
else:
modelo_lsi_1920 = models.LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100)
corpus_lsi_1920 = modelo_lsi[corpus_tfidf]
modelo_lsi_1920.save('Modelos/modelo_lsi_100_1920.md')
MmCorpus.serialize('Modelos/corpus_lsi_100_1920.cp', corpus_lsi_1920)
# 1920-1950:
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', IDs_1950, '-proc.txt', dic_gensim, stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_tfidf_1950.md') and \
os.path.exists('Modelos/corpus_tfidf_1950.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_1950.md')
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_1950.cp')
else:
modelo_tfidf = models.TfidfModel(corpus_gensim, normalize=True)
corpus_tfidf = modelo_tfidf[corpus_gensim]
modelo_tfidf.save('Modelos/modelo_tfidf_1950.md')
MmCorpus.serialize('Modelos/corpus_tfidf_1950.cp', corpus_tfidf)
if os.path.exists('Modelos/modelo_lsi_100_1950.md') and \
os.path.exists('Modelos/corpus_lsi_100_1950.cp'):
modelo_lsi_1950 = LsiModel.load('Modelos/modelo_lsi_100_1950.md')
corpus_lsi_1950 = MmCorpus('Modelos/corpus_lsi_100_1950.cp')
else:
modelo_lsi_1950 = models.LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100)
corpus_lsi_1950 = modelo_lsi[corpus_tfidf]
modelo_lsi_1950.save('Modelos/modelo_lsi_100_1950.md')
MmCorpus.serialize('Modelos/corpus_lsi_100_1950.cp', corpus_lsi_1950)
# 1950-2000:
dic_gensim = corpora.Dictionary()
corpus_gensim = GensimCorpus('CE-DOHS-proc/', IDs_2000, '-proc.txt', dic_gensim, stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_tfidf_2000.md') and \
os.path.exists('Modelos/corpus_tfidf_2000.cp'):
modelo_tfidf = TfidfModel.load('Modelos/modelo_tfidf_2000.md')
corpus_tfidf = MmCorpus('Modelos/corpus_tfidf_2000.cp')
else:
modelo_tfidf = models.TfidfModel(corpus_gensim, normalize=True)
corpus_tfidf = modelo_tfidf[corpus_gensim]
modelo_tfidf.save('Modelos/modelo_tfidf_2000.md')
MmCorpus.serialize('Modelos/corpus_tfidf_2000.cp', corpus_tfidf)
if os.path.exists('Modelos/modelo_lsi_100_2000.md') and \
os.path.exists('Modelos/corpus_lsi_100_2000.cp'):
modelo_lsi_2000 = LsiModel.load('Modelos/modelo_lsi_100_2000.md')
corpus_lsi_2000 = MmCorpus('Modelos/corpus_lsi_100_2000.cp')
else:
modelo_lsi_2000 = models.LsiModel(corpus_tfidf, id2word=dic_gensim, num_topics=100)
corpus_lsi_2000 = modelo_lsi[corpus_tfidf]
modelo_lsi_2000.save('Modelos/modelo_lsi_100_2000.md')
MmCorpus.serialize('Modelos/corpus_lsi_100_2000.cp', corpus_lsi_2000)
Identificação dos tópicos:
Abaixo, listamos , para cada período, os primeiros 30 tokens dos cinco tópicos iniciais, sem os respectivos pesos.
modelos = [modelo_lsi_1890, modelo_lsi_1920, modelo_lsi_1950, modelo_lsi_2000]
n_topicos = set(range(1,6))
periodos = ['1823-1890', '1891-1920', '1921-1950', '1951-2000']
topicos = pd.DataFrame(index=n_topicos, columns=periodos)
unicos = pd.DataFrame(index=n_topicos, columns=periodos)
comuns = []
for periodo, modelo in zip(periodos, modelos):
for i in n_topicos:
topicos.loc[i, periodo] = [token for (token, carga)
in modelo.show_topic(i-1, 30)
if len(token)>1]
unicos.loc[i, periodo] = set(topicos.loc[i, periodo])
comuns.extend(topicos.loc[i, periodo])
printmd('<br>**Tópicos iniciais para cada período:**')
topicos
Em seguida, apresentamos os tokens que aparecem apenas em cada tópico do conjunto de tópicos para cada período.
for periodo in periodos:
for i in n_topicos:
outros = n_topicos - set([i])
set_outros = set()
for j in outros:
set_outros = set_outros.union(topicos.loc[j,periodo])
unicos.loc[i, periodo] = sorted(set(topicos.loc[i, periodo]) - set_outros)
printmd('<br>**Tokens únicos em cada tópico por período:**')
unicos
Interpretação dos resultados:
A separação da análise por período parece ter ajudado a distinguir melhor os tópicos gerados pelo modelo LSI. Chegamos às seguintes conclusões:
Todos os tópicos do período de 1823 a 1890 incluem termos mais formais, muitos deles aparentemente relativos a transações comerciais ou oficiais (ex., ofício, negócio, procuração, execução, embolçar).
Os tópicos do período de 1890 a 1920 foram os que apresentaram diferenças mais claras entre si. Os termos formais ainda predominam, embora apareçam palavras tais como 'coração' e 'beijo'. Aparecem também temas relacionados aos conflitos no cangaço e a disputas políticas.
No período de 1920 a 1950, os tópicos são pouco distintos. Em geral, percebe-se um uma maior ocorrência de termos relacionados a questões oficiais, como 'escrivão', 'cargo', 'nomeação', e 'concurso'.
Por fim, entre 1950 e 2000, os primeiros quatro tópicos tratam essencialmente de questões pessoais, e o quinto, de questões políticas.
Na segunda etapa da análise, investigamos mudanças no significado das palavras do corpus com o modelo Word2Vec implementado na biblioteca gensim. Radim Řehůřek, autor do gensim, elaborou um tutorial com mais detalhes sobre a implementação do Word2Vec na biblioteca e exemplos do seu uso (https://rare-technologies.com/word2vec-tutorial/).
A implementação do gensim permite que as frases que compõem os documentos sejam alimentadas uma a uma, de forma a otimizar a memória. Adaptamos a classe GensimCorpus criada anteriormente para tirar proveito dessa funcionalidade.
class Word2VecCorpus:
def __init__(self, pasta, IDs, sufixo, stop_words, lower=True, lema=False, nomes=False, num=False):
self.pasta = pasta
self.IDs = IDs
self.sufixo = sufixo
self.stop = stop_words
self.lower = lower
self.lema = lema
self.nomes = nomes
self.num = num
def __iter__(self):
for counter, id in enumerate(self.IDs):
fileid = self.pasta + id + self.sufixo
with open(fileid, mode="r", encoding="utf-8") as doc:
linhas = doc.readlines()
for linha in linhas:
linha = nlp(linha.strip('\n'))
if self.nomes and not self.lema:
tokens = [token.orth_ for token in linha if token.pos_ != 'PROPN']
elif self.lema and not self.nomes:
tokens = [token.lemma_ for token in linha]
elif self.nomes and self.lema:
tokens = [token.lemma_ for token in linha if token.pos_ != 'PROPN']
else:
tokens = [token.orth_ for token in linha]
if self.lower:
tokens = [token.lower() for token in tokens]
tokens = [token for token in tokens if len(token) > 1 and token not in self.stop]
if self.num:
tokens = [re.sub('[$:0-9]*', '', token)
for token in tokens]
tokens = [token for token in tokens if len(token) > 1]
if len(tokens) > 0:
yield tokens
Primeiramente, realizamos a análise para todos os documentos do corpus, sem dividi-los por período. O corpus do modelo Word2Vec foi criado com as opções de remoção de stop words, nomes próprios e números, e passagem para minúsculas.
corpus_w2v = Word2VecCorpus('CE-DOHS-proc/', metadados.ID, '-proc.txt', stop_words=stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_w2v.md'):
modelo_w2v = Word2Vec.load('Modelos/modelo_w2v.md')
else:
modelo_w2v = Word2Vec(sentences=corpus_w2v, workers=32, batch_words=200)
modelo_w2v.save('Modelos/modelo_w2v.md')
vocab_w2v = list(modelo_w2v.wv.vocab.keys())
mais_freq = modelo_w2v.wv.index2entity[:500]
menos_freq = modelo_w2v.wv.index2entity[-500:]
Para cada palavra nas 500 mais frequentes no vocabulário do nosso corpus, identificamos as cinco palavras mais similares a ela, usando o modelo Word2Vec. Os resultados são mostrados no dataframe a seguir, onde:
similares = pd.DataFrame(columns=['Token', 'Frequência', 'Similares'])
tokens = mais_freq
for token in tokens:
similares = similares.append({'Token': token,
'Frequência': comuns.count(token),
'Similares': [similar for (similar, carga)
in modelo_w2v.wv.most_similar(token, topn=5)]},
ignore_index=True)
similares = similares.sort_values(by=['Frequência'], ascending=False)
similares
Em seguida, exibimos as mesmas informações para dez palavras selecionadas aleatoriamente dentre as 500 mais frequentes no corpus.
similares = pd.DataFrame(columns=['Token', 'Frequência', 'Similares'])
tokens = [mais_freq[i] for i in sample(range(0, 499), 10)]
for token in tokens:
similares = similares.append({'Token': token,
'Frequência': comuns.count(token),
'Similares': [similar for (similar, carga)
in modelo_w2v.wv.most_similar(token, topn=5)]},
ignore_index=True)
similares.sort_values(by=['Token'])
Por fim, mostramos cinco palavras mais similares a dez palavras selecionadas aleatoriamente dentre as 500 menos frequentes no corpus.
similares = pd.DataFrame(columns=['Token', 'Frequência', 'Similares'])
tokens = [menos_freq[i] for i in sample(range(0, 499), 10)]
for token in tokens:
similares = similares.append({'Token': token,
'Frequência': comuns.count(token),
'Similares': [similar for (similar, carga)
in modelo_w2v.wv.most_similar(token, topn=5)]},
ignore_index=True)
similares.sort_values(by=['Token'])
Os resultados foram, em sua grande maioria, insatisfatórios. Para alguns dos termos, o modelo Word2Vec foi capaz de identificar outros que parecem realmente estar a eles associados no contexto tratado no corpus (ex., para 'fiel', gerou as palavras 'amigo', 'velho', 'beijo', e 'coração'; para 'saudade', gerou 'casa', 'amigos', e 'filho'). Contudo, em geral, os termos identificados não parecem ter o mesmo sentido que o termo-alvo da análise (ex. para 'irmão', gerou 'outro', 'casa', 'vida', 'tendo', e 'dia'; para 'comida', gerou 'código', 'expor', 'fiscal', 'notas', e 'visitar'.
O problema pareceu independer da frequência com que o termo-alvo da análise aparecia nos assuntos identificados com o modelo LSI.
Um dos parâmetro do algoritmo do Word2Vec é o tamanho do lote (batch_size). De acordo com Li et al. (2019):
Na análise do corpus segmentado por períodos usando a configuração padrão do gensim, foram gerados alertas sugerindo a diminuição dos valores do batch em função da subutilização dos cores da CPU na criação do corpus do Word2Vec. Após várias reduções, chegamos ao valor de 200, que não gerou mais alertas. Esse valor é próximo da mediana do número de palavras nos documentos pré-processados do corpus, que foi de 210.
batch = 200 # configuração do parâmetro batch_size
corpus_w2v_1890 = Word2VecCorpus('CE-DOHS-proc/', IDs_1890, '-proc.txt', stop_words=stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_w2v_1890.md'):
modelo_w2v_1890 = Word2Vec.load('Modelos/modelo_w2v_1890.md')
else:
modelo_w2v_1890 = Word2Vec(sentences=corpus_w2v_1890, workers=32, batch_words=batch)
modelo_w2v_1890.save('Modelos/modelo_w2v_1890.md')
vocab_w2v_1890 = list(modelo_w2v_1890.wv.vocab.keys())
corpus_w2v_1920 = Word2VecCorpus('CE-DOHS-proc/', IDs_1920, '-proc.txt', stop_words=stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_w2v_1920.md'):
modelo_w2v_1920 = Word2Vec.load('Modelos/modelo_w2v_1920.md')
else:
modelo_w2v_1920 = Word2Vec(sentences=corpus_w2v_1920, workers=32, batch_words=batch)
modelo_w2v_1920.save('Modelos/modelo_w2v_1920.md')
vocab_w2v_1920 = list(modelo_w2v_1920.wv.vocab.keys())
corpus_w2v_1950 = Word2VecCorpus('CE-DOHS-proc/', IDs_1950, '-proc.txt', stop_words=stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_w2v_1950.md'):
modelo_w2v_1950 = Word2Vec.load('Modelos/modelo_w2v_1950.md')
else:
modelo_w2v_1950 = Word2Vec(sentences=corpus_w2v_1950, workers=32, batch_words=batch)
modelo_w2v_1950.save('Modelos/modelo_w2v_1950.md')
vocab_w2v_1950 = list(modelo_w2v_1950.wv.vocab.keys())
corpus_w2v_2000 = Word2VecCorpus('CE-DOHS-proc/', IDs_2000, '-proc.txt', stop_words=stopwords,
lower=True, lema=False, nomes=True, num=True)
if os.path.exists('Modelos/modelo_w2v_2000.md'):
modelo_w2v_2000 = Word2Vec.load('Modelos/modelo_w2v_2000.md')
else:
modelo_w2v_2000 = Word2Vec(sentences=corpus_w2v_2000, workers=32, batch_words=batch)
modelo_w2v_2000.save('Modelos/modelo_w2v_2000.md')
vocab_w2v_2000 = list(modelo_w2v_2000.wv.vocab.keys())
Primeiramente, buscamos palavras similares a termos dos assuntos identificados na análise com o modelo LSI, que pareciam estar relacionados a assuntos pessoais.
palavras = ['felicidade', 'feliz', 'saudade', 'saudades', 'velho', 'velha', 'homem', 'mulher', 'filhos', 'parente', 'morte']
modelos = [modelo_w2v_1890, modelo_w2v_1920, modelo_w2v_1950, modelo_w2v_2000]
vocabs = [vocab_w2v_1890,vocab_w2v_1920, vocab_w2v_1950, vocab_w2v_2000]
periodos = ['1823-1890', '1891-1920', '1921-1950', '1951-2000']
similares = pd.DataFrame(columns=['Palavra', '1823-1890', '1891-1920', '1921-1950', '1951-2000'])
similares.Palavra = palavras
for modelo, vocab, periodo in zip(modelos, vocabs, periodos):
for palavra in palavras:
if palavra in vocab:
similares.loc[similares.Palavra == palavra, periodo] = [[similar
for (similar, carga)
in modelo.wv.most_similar(palavra, topn=5)]]
else:
similares.loc[similares.Palavra == palavra, periodo] = ''
similares
De forma geral, a qualidade dos resultados foi similar à da análise global: a identificação do significado de cada termos a partir dos termos similares gerados com o modelo Word2Vec é bastante difícil. As palavras fornecidas são substantitvos e adjetivos, mas há nas listas de palavras similares diversos verbos. Além disso, em vários casos, os termos dessas listas são bastante distintos (ex., 'juiz', 'vista', 'fiz', 'acha', 'criado').
Mesmo assim, conseguimos identificar algumas relações interessantes:
No próximo passo da análise, procuramos termos similares àqueles relacionados a assuntos oficiais e políticos. Dado que as cartas são de pessoas da região nordeste do país, incluímos também a palvra 'seca', que frequentemente é associada a questões políticas naquele contexto.
palavras = ['interesse', 'cargo', 'governo', 'governador', 'presidente', 'país', 'seca', 'político', 'eleição', 'política']
similares = pd.DataFrame(columns=['Palavra', '1823-1890', '1891-1920', '1921-1950', '1951-2000'])
similares.Palavra = palavras
for modelo, vocab, periodo in zip(modelos, vocabs, periodos):
for palavra in palavras:
if palavra in vocab:
similares.loc[similares.Palavra == palavra, periodo] = [[similar
for (similar, carga)
in modelo.wv.most_similar(palavra, topn=5)]]
else:
similares.loc[similares.Palavra == palavra, periodo] = ''
similares
As seguintes relações foram identificadas:
Por fim, buscamos palavras similares a termos associados a conflitos armados. Esse foi um dos assuntos identificados na análise de tópicos com o modelo LSI.
palavras = ['luta', 'guerra', 'capitão', 'coronel', 'general']
similares = pd.DataFrame(columns=['Palavra', '1823-1890', '1891-1920', '1921-1950', '1951-2000'])
similares.Palavra = palavras
for modelo, vocab, periodo in zip(modelos, vocabs, periodos):
for palavra in palavras:
if palavra in vocab:
similares.loc[similares.Palavra == palavra, periodo] = [[similar
for (similar, carga)
in modelo.wv.most_similar(palavra, topn=5)]]
else:
similares.loc[similares.Palavra == palavra, periodo] = ''
similares
Todos os termos são associados a outros mais genéricos cujos significados estão mais relacionados a relacionamentos pessoais do que a questões oficiais ou relativas a conflitos. Vale notar que nenhum dos termos aparece no quarto período, e que os quatro primeiros ('luta', 'guerra', 'capitão' e 'coronel') aparecem apenas nos dois primeiros.
Em seguida, geramos um dataframe com os cinco termos mais similares a cada termo do vocabulário, para cada um dos períodos considerados na análise. Observa-se que poucos dos termos do vocabulário aparecem em documentos de todos os períodos.
modelos = [modelo_w2v_1890, modelo_w2v_1920, modelo_w2v_1950, modelo_w2v_2000]
vocabs = [vocab_w2v_1890,vocab_w2v_1920, vocab_w2v_1950, vocab_w2v_2000]
periodos = ['1823-1890', '1891-1920', '1921-1950', '1951-2000']
vocabulario = []
for i in vocabs:
vocabulario.extend(i)
similares = pd.DataFrame(columns=['Palavra', '1823-1890', '1891-1920', '1921-1950', '1951-2000','Presente', 'Diferenças'])
similares.Palavra = list(sorted(set(vocabulario)))
for modelo, vocab, periodo in zip(modelos, vocabs, periodos):
for palavra in vocabulario:
if palavra in vocab:
similares.loc[similares.Palavra == palavra, periodo] = [[similar
for (similar, carga)
in modelo.wv.most_similar(palavra, topn=5)]]
else:
similares.loc[similares.Palavra == palavra, periodo] = ['']
similares[['Palavra', '1823-1890', '1891-1920', '1921-1950', '1951-2000']]
Para tentar identificar palavras cujo sentido tenha sofrido mudanças ao longo do tempo, criamos um novo dataframe com termos do vocabulário que aparecem em cartas dos quatro períodos analisados, ordenando-os pela soma do número de palavras similares que mudam entre períodos consecutivos. Conforme indicado abaixo, havia 272 palavras presentes em documentos dos quatro períodos.
for i in range(0, similares.shape[0]):
x = similares.loc[i, ['1823-1890', '1891-1920', '1921-1950', '1951-2000']]
similares.loc[i, 'Presente'] = sum(map(lambda x: 0 if len(x) == 0 else 1, x))
for palavra in similares.loc[similares.Presente == 4, 'Palavra']:
lista = set(similares.loc[similares.Palavra==palavra, '1823-1890'].to_list()[0])
diferentes = 0
for periodo in ['1891-1920', '1921-1950', '1951-2000']:
lista = lista - set(similares.loc[similares.Palavra==palavra, periodo].to_list()[0])
diferentes += len(lista)
lista = set(similares.loc[similares.Palavra==palavra, periodo].to_list()[0])
similares.loc[similares.Palavra==palavra, 'Diferenças'] = diferentes
diferentes = similares.loc[similares.Presente == 4,
['Palavra', '1823-1890', '1891-1920', '1921-1950', '1951-2000', 'Diferenças']] \
.sort_values(by=['Diferenças'], ascending=False)
printmd('**Número de palavras presentes em documentos dos quatro períodos: <font color="gray">' +
str(diferentes.shape[0]) + '</font>**')
printmd('<br/>**Primeras palavras com maior número de diferenças entre períodos:**')
diferentes.head(30)
Dentre as palavras listadas acima, pode-se destacar 'verdade', que aparece relacionada a termos que remetem a questões mais formais ou oficiais nos dois primeiros períodos (ex., 'mandar', 'ordens', 'ações', 'eleição'), e no último aparece associada a termos de cunho mais pessoal (ex., 'fé', 'abraço', 'querido', 'saudade'). Outrossim, os resultados não revelaram palavras cujo significado tenha claramente se alterado ao longo do tempo.
A seguir, exibimos os resultados de similaridade para dez palavras escolhidas aleatoriamente no novo dataframe.
diferentes.iloc[[i for i in sample(range(0, diferentes.shape[0]), 10)]]\
.sort_values(by=['Diferenças'], ascending=False)
Novamente, não foi possível identificar mudanças de significado importantes ao longo do período da análise.
Na última etapa da nossa análise, criamos gráficos das palavras do vocabulário localizadas no espaço dos dois primeiros vetores gerados pelo Word2Vec.
def reduce_dims(model, n_dims=2):
vectors = [] # positions in vector space
labels = [] # keep track of words to label our data again later
counts = [] # counts for the word
for word in model.wv.vocab:
vectors.append(model.wv[word])
labels.append(word)
counts.append(model.wv.vocab[word].count)
# convert both lists into numpy vectors for reductionDe forma geral,
vectors = np.asarray(vectors)
labels = np.asarray(labels)
# reduce using t-SNE
tsne = TSNE(n_components=n_dims, random_state=0)
vectors = tsne.fit_transform(vectors)
x_vals = [v[0] for v in vectors]
y_vals = [v[1] for v in vectors]
if n_dims == 3:
z_vals = [v[2] for v in vectors]
else:
z_vals = []
return x_vals, y_vals, z_vals, labels, counts
def plot_with_plotly(x_vals, y_vals, z_vals, labels, counts, title):
if z_vals:
fig = go.Figure(
data=go.Scatter3d(x=x_vals, y=y_vals, mode='text', text=labels),
layout=go.Layout(
title=title,
template='plotly_white')
)
else:
fig = go.Figure(
data=go.Scatter(x=x_vals, y=y_vals, mode='text', text=labels),
layout=go.Layout(
title=title,
template='plotly_white')
)
fig.show()
x_vals, y_vals, z_vals, labels, counts = reduce_dims(modelo_w2v)
title = 'Visualização de palavras para todo o corpus'
plot_with_plotly(x_vals, y_vals, z_vals, labels, counts, title)
O gráfico mostra duas curvas claramente separadas. Uma análise mais detalhada dos termos que as constituem sugere os seguintes padrões:
É possível que a inclusão de outras dimensões (ex., num gráfico 3D ou com o uso de cores) separe os pontos que formam as curvas em clusters mais distintos (infelizmente, quando tentamos fazer isso usando a biblioteca plotly, o gráfico não pode ser renderizado no notebook).
Por fim, geramos gráficos similares para cada período considerado em nossa análise.
x_vals, y_vals, z_vals, labels, counts = reduce_dims(modelo_w2v_1890)
title = 'Visualização de palavras para 1823-1890'
plot_with_plotly(x_vals, y_vals, z_vals, labels, counts, title)
x_vals, y_vals, z_vals, labels, counts = reduce_dims(modelo_w2v_1920)
title = 'Visualização de palavras para 1890-1920'
plot_with_plotly(x_vals, y_vals, z_vals, labels, counts, title)
x_vals, y_vals, z_vals, labels, counts = reduce_dims(modelo_w2v_1950)
title = 'Visualização de palavras para 1920-1950'
plot_with_plotly(x_vals, y_vals, z_vals, labels, counts, title)
x_vals, y_vals, z_vals, labels, counts = reduce_dims(modelo_w2v_2000)
title = 'Visualização de palavras para 1950-2000'
plot_with_plotly(x_vals, y_vals, z_vals, labels, counts, title)
Uma vez que os espaços vetorias de cada gráfico são diferentes, não é possível uma comparação direta. Observamos, no entanto, que a dispersão das palavras nos quatro espaços é razoavelmente distinta:
Cluster no período 1890-1920:

As palavras com os maiores valores de ordenadas do cluster são em sua grande maioria relacionadas a questões pessoais (ex., 'família', 'amigos', 'saúde', 'casa'). Na medida em que ordena diminui, surgem palavras relacionadas a temas oficiais ou formais, como 'governo', 'serviços', 'política' e 'quantia'.
Cluster no período 1920-1950:

No período de 1920 a 1950, a grande maioria das palavras do cluster refletem temas pessoais, como 'amizade', 'amigo', 'família', e 'saudades'.
Cluster no período 1950-2000:

Como no período anterior, as palavras do cluster deste período também dizem respeito a questões pessoais mais informais. Nele predominam termos tais como 'lembrança', 'amigo', 'amor', 'querido', e 'beijo'.
O desempenho dos dois modelos utilizados na análise (LSI e Word2Vec) foi aquém do que geralmente é reportado na literatura e em artigos da Web. No entanto, a maior parte das análises reportadas são de corpora na língua inglesa. Claramente, como já mencionado, os modelos da biblioteca spaCy precisam ser melhor treinados para a língua portuguesa. Em particular, o desempenho do modelo de lematização foi consideravelmente pior do que o do tokenizador e do modelo de Part-Of-Speach (POS).
Além da língua, outras caraterísticas do corpus analisado podem ter influenciado na qualidade dos resultados:
Apesar das dificuldades, alguns pontos interessantes emergiram da análise. Por exemplo, o modelo LSI tendeu a separar temas relativos a relacionamentos pessoais dos temas relativos a questões políticas ou mais formais. Os resultados do modelo Word2Vec foram menos relevantes, embora tenha sido possível distinguir algumas alterações de significado de palavras ao longo dos quatro períodos analisados.
Como próximos passos, sugerimos:
Por fim, estimula-se a comunidade brasileira e dos demais países de língua portuguesa a desenvolverem modelos de lematização adequadamente treinados em nosso idioma.